<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Image/Manga Translator</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@unocss/reset/tailwind.min.css" />
    <script src="https://cdn.jsdelivr.net/npm/petite-vue@0.4.1/dist/petite-vue.iife.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@unocss/runtime@0.30.5/uno.global.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@iconify/iconify@2.2.0/dist/iconify.min.js"></script>
    <style>
      [v-cloak],
      [un-cloak] {
        display: none;
      }
    </style>
  </head>
  <body>
    <form
      action="#"
      class="flex py-8 w-full min-h-100vh justify-center items-center"
      @submit.prevent="onsubmit"
      @vue:mounted="onmounted"
      v-scope
      v-cloak
      un-cloak
    >
      <div class="flex flex-col w-85ch h-full justify-center gap-2">
        <h1 class="text-center text-lg font-light">Image/Manga Translator</h1>
        <div class="flex mx-4 justify-start items-end">
          <div class="flex gap-4">
            <div class="flex items-center" title="Detection resolution">
              <i class="iconify" data-icon="carbon:fit-to-screen"></i>
              <div class="relative">
                <select class="w-9ch appearance-none bg-transparent border-b border-gray-300" v-model="detectionResolution">
                  <option value="S">1024px</option>
                  <option value="M">1536px</option>
                  <option value="L">2048px</option>
                  <option value="X">2560px</option>
                </select>
                <i class="iconify absolute top-1.5 right-1 pointer-events-none" data-icon="carbon:chevron-down"></i>
              </div>
            </div>
            <div class="flex items-center gap-1" title="Text detector">
              <i class="iconify" data-icon="carbon:search-locate"></i>
              <div class="relative">
                <select class="w-9ch appearance-none bg-transparent border-b border-gray-300" v-model="textDetector">
                  <option value="auto">Default</option>
                  <option value="ctd">CTD</option>
                </select>
                <i class="iconify absolute top-1.5 right-1 pointer-events-none" data-icon="carbon:chevron-down"></i>
              </div>
            </div>
            <div class="flex items-center gap-1" title="Render text orientation">
              <i class="iconify" data-icon="carbon:text-align-left"></i>
              <div class="relative">
                <select class="w-12ch appearance-none bg-transparent border-b border-gray-300" v-model="renderTextDirection">
                  <option value="auto">Auto</option>
                  <option value="h">Horizontal</option>
                  <option value="v">Vertical</option>
                </select>
                <i class="iconify absolute top-1.5 right-1 pointer-events-none" data-icon="carbon:chevron-down"></i>
              </div>
            </div>
            <div class="flex items-center gap-1" title="Translator">
              <i class="iconify" data-icon="carbon:operations-record"></i>
              <div class="relative">
                <select class="w-9ch appearance-none bg-transparent border-b border-gray-300" v-model="translator">
                  <option v-for="key in validTranslators" :value="key">{{getTranslatorName(key)}}</option>
                </select>
                <i class="iconify absolute top-1.5 right-1 pointer-events-none" data-icon="carbon:chevron-down"></i>
              </div>
            </div>
            <div class="flex items-center gap-1" title="Target language">
              <i class="iconify" data-icon="carbon:language"></i>
              <div class="relative">
                <select class="w-15ch appearance-none bg-transparent border-b border-gray-300" v-model="targetLanguage">
                  <option value="CHS">简体中文</option>
                  <option value="CHT">繁體中文</option>
                  <option value="JPN">日本語</option>
                  <option value="ENG">English</option>
                  <option value="KOR">한국어</option>
                  <option value="VIN">Tiếng Việt</option>
                  <option value="CSY">čeština</option>
                  <option value="NLD">Nederlands</option>
                  <option value="FRA">français</option>
                  <option value="DEU">Deutsch</option>
                  <option value="HUN">magyar nyelv</option>
                  <option value="ITA">italiano</option>
                  <option value="PLK">polski</option>
                  <option value="PTB">português</option>
                  <option value="ROM">limba română</option>
                  <option value="RUS">русский язык</option>
                  <option value="ESP">español</option>
                  <option value="TRK">Türk dili</option>
                  <option value="IND">Indonesia</option>
                </select>
                <i class="iconify absolute top-1.5 right-1 pointer-events-none" data-icon="carbon:chevron-down"></i>
              </div>
            </div>
          </div>
        </div>
        <div v-if="result" class="flex flex-col items-center">
          <img class="my-2" :src="resultUri" />
          <button class="px-2 py-1 text-center rounded-md text-blue-800 border-2 border-blue-300" @click="clear">Upload another</button>
        </div>
        <div v-else-if="status" class="grid w-full h-116 place-content-center rounded-2xl border-2 border-dashed border-gray-600">
          <div v-if="error" class="flex flex-col items-center gap-2">
            <div style="color: crimson">{{ statusText }}</div>
            <button class="px-2 py-1 text-center rounded-md text-blue-800 border-2 border-blue-300" @click="clear">Upload another</button>
          </div>
          <div v-else class="flex flex-col items-center gap-2">
            <i class="iconify w-8 h-8 text-gray-500 animate-spin" data-icon="carbon:progress-bar-round"></i>
            <div>{{ statusText }}</div>
          </div>
        </div>
        <label
          v-else
          class="grid w-full h-116 place-content-center rounded-2xl border-2 border-dashed border-gray-600 cursor-pointer"
          for="file"
          @dragenter.prevent
          @dragover.prevent
          @dragleave.prevent
          @drop.prevent="ondrop"
        >
          <div v-if="file" class="flex flex-col items-center gap-2">
            <div><span class="iconify-inline inline-block mr-2 scale-125" data-icon="carbon:image-search"></span>File Preview</div>
            <img class="max-w-72 max-h-72" :src="fileUri" />
            <button type="submit" class="px-2 py-1 rounded-md text-blue-800 border-2 border-blue-300">Translate</button>
            <div class="text-sm text-gray-600">Click the empty space or paste/drag a new one to replace</div>
          </div>
          <div v-else class="flex flex-col items-center gap-2">
            <i class="iconify w-8 h-8 text-gray-500" data-icon="carbon:cloud-upload"></i>
            <div>Paste an image, click to select one or drag and drop here</div>
          </div>
          <input id="file" type="file" accept="image/png,image/jpeg,image/bmp,image/webp" class="hidden" @change="onfilechange" />
        </label>
        <div class="flex justify-center gap-2">
          <div>
            Please consider supporting us by
            <a class="underline underline-blue-400" href="https://ko-fi.com/voilelabs" target="_blank" rel="noopener noreferrer">Ko-fi</a>
            or
            <a class="underline underline-blue-400" href="https://www.patreon.com/voilelabs" target="_blank" rel="noopener noreferrer"
              >Patreon</a
            >!
          </div>
          <a
            class="underline underline-blue-400"
            href="https://github.com/zyddnys/manga-image-translator"
            target="_blank"
            rel="noopener noreferrer"
            >Source Code</a
          >
        </div>
      </div>
    </form>
    <script>
      const BASE_URI = '/'
      const acceptTypes = ['image/png', 'image/jpeg', 'image/bmp', 'image/webp']

      function formatSize(bytes) {
        const k = 1024
        const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
        if (bytes === 0) return '0B'
        const i = Math.floor(Math.log(bytes) / Math.log(k))
        return `${(bytes / k ** i).toFixed(2)}${sizes[i]}`
      }
      function formatProgress(loaded, total) {
        return `${formatSize(loaded)}/${formatSize(total)}`
      }

      PetiteVue.createApp({
        onmounted() {
          window.addEventListener('paste', this.onpaste)
        },

        file: null,
        get fileUri() {
          return this.file ? URL.createObjectURL(this.file) : null
        },
        detectionResolution: 'M',
        textDetector: 'auto',
        renderTextDirection: 'auto',
        translator: 'youdao',
        validTranslators: ['youdao', 'baidu', 'google', 'deepl', 'papago', 'caiyun', 'offline', 'gpt3.5', 'none'],
        getTranslatorName(key) {
          if (key == 'none')
            return "No Text"
          return key ? key[0].toUpperCase() + key.slice(1) : "";
        },
        targetLanguage: 'CHS',
        ondrop(e) {
          const file = e.dataTransfer?.files?.[0]
          if (file && acceptTypes.includes(file.type)) {
            this.file = file
          }
        },
        onfilechange(e) {
          const file = e.target.files?.[0]
          if (file && acceptTypes.includes(file.type)) {
            this.file = file
          }
        },
        onpaste(e) {
          const items = (e.clipboardData || e.originalEvent.clipboardData).items
          for (const item of items) {
            if (item.kind === 'file') {
              const file = item.getAsFile()
              if (!file || !acceptTypes.includes(file.type)) continue
              this.file = file
            }
          }
        },

        progress: null,
        status: null,
        queuePos: null,
        cachedStatusText: '',
        get statusText() {
          var newStatusText = this._statusText
          if (newStatusText != null && newStatusText != this.cachedStatusText) {
            this.cachedStatusText = newStatusText
          }
          return this.cachedStatusText
        },
        get _statusText() {
          switch (this.status) {
            case 'upload': {
              if (this.progress) {
                return `Uploading (${this.progress})`
              } else {
                return 'Uploading'
              }
            }
            case 'download':
              if (this.progress) {
                return `Downloading (${this.progress})`
              } else {
                return 'Downloading'
              }

            case 'pending':
              if (this.queuePos) {
                return `Queuing, your position is ${this.queuePos}`
              } else {
                return 'Processing'
              }
            case 'detection':
              return 'Detecting texts'
            case 'ocr':
              return 'Running OCR'
            case 'mask-generation':
              return 'Generating text mask'
            case 'inpainting':
              return 'Running inpainting'
            case 'upscaling':
              return 'Running upscaling'
            case 'translating':
              return 'Translating'
            case 'rendering':
              return 'Rendering translated texts'
            case 'error':
              return 'Something went wrong, please try again'
            case 'error-upload':
              return 'Upload failed, please try again'
            case 'error-lang':
              return 'Your target language is not supported by the chosen translator'
            case 'error-translating':
              return 'Did not get any text back from the text translation service'
            case 'error-too-large':
              return 'Image size too large (greater than 8000x8000 px)'
            case 'error-disconnect':
              return 'Lost connection to server'
          }
        },
        get error() {
          return /^error/.test(this.status)
        },
        result: null,
        get resultUri() {
          return this.result ? URL.createObjectURL(this.result) : null
        },
        onsubmit(e) {
          if (!this.file) return

          this.progress = null
          this.queuePos = null
          this.status = 'upload'

          const formData = new FormData()
          formData.append('file', this.file)
          formData.append('size', this.detectionResolution)
          formData.append('detector', this.textDetector)
          formData.append('direction', this.renderTextDirection)
          formData.append('translator', this.translator)
          formData.append('target_lang', this.targetLanguage)

          const xhr = new XMLHttpRequest()
          xhr.open('POST', `${BASE_URI}submit`, true)
          xhr.onerror = (e) => {
            this.status = 'error-disconnect'
          }
          xhr.upload.onprogress = (e) => {
            if (e.lengthComputable) this.progress = formatProgress(e.loaded, e.total)
          }
          xhr.onload = async () => {
            if (xhr.status !== 200) {
              this.status = 'error-upload'
              return
            }

            response = JSON.parse(xhr.responseText)
            const task_id = response['task_id']
            this.status = response['status']
            if (this.error)
              return

            this.status = 'pending'

            async function tryFetchTaskState() {
              try {
                return await (await fetch(`${BASE_URI}task-state?taskid=${task_id}`)).json()
              }
              catch {
                return null
              }
            }

            for (;;) {
              const timer = new Promise((resolve) => setTimeout(resolve, 500))
              const res = await tryFetchTaskState()
              if (res == null) {
                this.status = 'error-disconnect'
                break
              }
              const { state, finished, waiting } = res
              // console.log(state, finished, waiting)

              if (finished && !state.startsWith('error')) {
                this.progress = null
                this.status = 'download'

                const xhrDownload = new XMLHttpRequest()
                xhrDownload.open('GET', `${BASE_URI}result/${task_id}`, true)
                xhrDownload.responseType = 'blob'
                xhrDownload.onprogress = (e) => {
                  if (e.lengthComputable) this.progress = formatProgress(e.loaded, e.total)
                }
                xhrDownload.onload = () => {
                  this.result = xhrDownload.response
                  this.status = null
                }
                xhrDownload.send()

                break
              }

              this.status = state
              this.queuePos = waiting

              if (/^error/.test(state)) {
                break
              }

              await timer
            }
          }
          xhr.send(formData)
        },
        clear() {
          this.file = null
          this.result = null
          this.status = null
        },
      }).mount()
    </script>
  </body>
</html>
